Coverage Report

Created: 2025-11-02 11:31

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
D:\a\csshw\csshw\src\cli.rs
Line
Count
Source
1
//! CLI interface
2
3
use crate::client::main as client_main;
4
use crate::daemon::{main as daemon_main, resolve_cluster_tags};
5
use crate::utils::config::{ClientConfig, Cluster, Config, ConfigOpt, DaemonConfig};
6
use crate::utils::windows::WindowsApi;
7
use crate::{
8
    get_console_window_handle, init_logger, is_launched_from_gui, spawn_console_process,
9
    WindowsSettingsDefaultTerminalApplicationGuard,
10
};
11
use clap::{ArgAction, CommandFactory, Parser, Subcommand};
12
13
#[cfg(test)]
14
use mockall::{automock, predicate::*};
15
use windows::Win32::UI::HiDpi::PROCESS_PER_MONITOR_DPI_AWARE;
16
17
const PKG_NAME: &str = env!("CARGO_PKG_NAME");
18
19
/// Cluster SSH tool for Windows inspired by csshX
20
///
21
/// The main CLI arguments
22
#[derive(Parser, Debug)]
23
#[clap(author, version, about, long_about = None)]
24
pub struct Args {
25
    /// Optional subcommand
26
    /// Usually not specified by the user
27
    #[clap(subcommand)]
28
    command: Option<Commands>,
29
    /// Optional username used to connect to the hosts
30
    #[clap(long, short = 'u')]
31
    username: Option<String>,
32
    /// Optional port used for all SSH connections
33
    #[clap(long, short = 'p')]
34
    port: Option<u16>,
35
    /// Hosts and/or cluster tag(s) to connect to
36
    ///
37
    /// Hosts or cluster tags might use brace expansion,
38
    /// but need to be properly quoted.
39
    ///
40
    /// E.g.: `csshw.exe "host{1..3}" hostA`
41
    ///
42
    /// Hosts can include a username which will take precedence over the
43
    /// username given via the `-u` option and over any ssh config value.
44
    ///
45
    /// E.g.: `csshw.exe -u user3 user1@host1 userA@hostA host3`
46
    ///
47
    /// Hosts can include a port number which will take precedence over the
48
    /// port given via the `-p` option.
49
    ///
50
    /// E.g.: `csshw.exe -p 33 host1:11 host2:22 host3`
51
    ///
52
    /// If no hosts are provided and the application is launched in a new console window
53
    /// (e.g. by double clicking the executable in the File Explorer),
54
    /// it will launch in interactive mode.
55
    #[clap(required = false, global = true)]
56
    hosts: Vec<String>,
57
    /// Enable extensive logging
58
    #[clap(short, long, action=ArgAction::SetTrue)]
59
    debug: bool,
60
}
61
62
/// The ``command`` CLI subcommand
63
#[derive(Debug, Subcommand, PartialEq)]
64
enum Commands {
65
    /// Subcommand that will launch a single client window
66
    ///
67
    /// connecting to the given host with the given username.
68
    /// It will also try to read input from a daemon via the named pipe.
69
    Client {
70
        /// Host to connect to
71
        host: String,
72
    },
73
    /// Subcommand that will launch the daemon window.
74
    ///
75
    /// The daemon is responsible to launch the client windows,
76
    /// one for each given host.
77
    /// For each client a named pipe will be created and any keystrokes
78
    /// the daemon window receives are forwarded via the pipes to all the clients.
79
    /// Also handles control mode.
80
    Daemon {},
81
}
82
83
/// Main Entrypoint struct
84
///
85
/// Used to implement the entrypoint functions of the different
86
/// subcommands
87
pub struct MainEntrypoint;
88
89
/// Trait for Args operations to enable mocking in tests
90
#[cfg_attr(test, automock)]
91
pub trait ArgsCommand {
92
    /// Print help message
93
    fn print_help(&self) -> Result<(), std::io::Error>;
94
}
95
96
/// Default implementation of ArgsCommand trait
97
pub struct CLIArgsCommand;
98
99
impl ArgsCommand for CLIArgsCommand {
100
0
    fn print_help(&self) -> Result<(), std::io::Error> {
101
0
        return Args::command().print_help();
102
0
    }
103
}
104
105
/// Trait for logger initialization to enable mocking in tests
106
#[cfg_attr(test, automock)]
107
pub trait LoggerInitializer {
108
    /// Initialize logger with the given name
109
    fn init_logger(&self, name: &str);
110
}
111
112
/// Default implementation of LoggerInitializer trait
113
pub struct CLILoggerInitializer;
114
115
impl LoggerInitializer for CLILoggerInitializer {
116
0
    fn init_logger(&self, name: &str) {
117
0
        init_logger(name);
118
0
    }
119
}
120
121
/// Trait defining the entrypoint functions of the different
122
/// subcommands
123
#[cfg_attr(test, automock)]
124
pub trait Entrypoint {
125
    /// Entrypoint for the client subcommand
126
    fn client_main<W: WindowsApi + 'static>(
127
        &mut self,
128
        windows_api: &W,
129
        host: String,
130
        username: Option<String>,
131
        port: Option<u16>,
132
        config: &ClientConfig,
133
    ) -> impl std::future::Future<Output = ()> + Send;
134
    /// Entrypoint for the daemon subcommand
135
    fn daemon_main<W: WindowsApi + Clone + 'static>(
136
        &mut self,
137
        windows_api: &W,
138
        hosts: Vec<String>,
139
        username: Option<String>,
140
        port: Option<u16>,
141
        config: &DaemonConfig,
142
        clusters: &[Cluster],
143
        debug: bool,
144
    ) -> impl std::future::Future<Output = ()> + Send;
145
    /// Entrypoint for the main command
146
    fn main<W: WindowsApi + 'static>(
147
        &mut self,
148
        windows_api: &W,
149
        config_path: &str,
150
        config: &Config,
151
        args: Args,
152
    );
153
}
154
155
impl Entrypoint for MainEntrypoint {
156
0
    async fn client_main<W: WindowsApi>(
157
0
        &mut self,
158
0
        windows_api: &W,
159
0
        host: String,
160
0
        username: Option<String>,
161
0
        port: Option<u16>,
162
0
        config: &ClientConfig,
163
0
    ) {
164
0
        client_main(windows_api, host, username, port, config).await;
165
0
    }
166
167
0
    async fn daemon_main<W: WindowsApi + Clone + 'static>(
168
0
        &mut self,
169
0
        windows_api: &W,
170
0
        hosts: Vec<String>,
171
0
        username: Option<String>,
172
0
        port: Option<u16>,
173
0
        config: &DaemonConfig,
174
0
        clusters: &[Cluster],
175
0
        debug: bool,
176
0
    ) {
177
0
        daemon_main(windows_api, hosts, username, port, config, clusters, debug).await;
178
0
    }
179
180
0
    fn main<W: WindowsApi + 'static>(
181
0
        &mut self,
182
0
        windows_api: &W,
183
0
        config_path: &str,
184
0
        config: &Config,
185
0
        args: Args,
186
0
    ) {
187
0
        confy::store_path(config_path, config).unwrap();
188
189
0
        let mut daemon_args: Vec<String> = Vec::new();
190
0
        if args.debug {
191
0
            daemon_args.push("-d".to_string());
192
0
        }
193
0
        if let Some(username) = args.username {
194
0
            daemon_args.push("-u".to_string());
195
0
            daemon_args.push(username);
196
0
        }
197
0
        if let Some(port) = args.port {
198
0
            daemon_args.push("-p".to_string());
199
0
            daemon_args.push(port.to_string());
200
0
        }
201
0
        daemon_args.push("daemon".to_string());
202
        // Order is important here. If the hosts are passed before the daemon subcommand
203
        // it will not be recognizes as such and just be passed along as one of the hosts.
204
0
        daemon_args.extend(
205
0
            resolve_cluster_tags(
206
0
                args.hosts.iter().map(|host| return &**host).collect(),
207
0
                &config.clusters,
208
            )
209
0
            .into_iter()
210
0
            .map(|host| return host.to_string()),
211
        );
212
0
        let _guard = WindowsSettingsDefaultTerminalApplicationGuard::new();
213
        // We must wait for the window to actually launch before dropping the _guard as we might otherwise
214
        // reset the configuration before the window was launched
215
0
        let _ = get_console_window_handle(
216
0
            windows_api,
217
0
            spawn_console_process(windows_api, &format!("{PKG_NAME}.exe"), daemon_args)
218
0
                .expect("Failed to create process")
219
0
                .dwProcessId,
220
0
        );
221
0
    }
222
}
223
224
/// Display the interactive mode prompt and instructions
225
0
fn show_interactive_prompt() {
226
0
    println!("\n=== Interactive Mode ===");
227
0
    println!("Enter your {PKG_NAME} arguments (or press Enter to exit):");
228
0
    println!("Example: -u myuser host1 host2 host3");
229
0
    println!("Example: --help");
230
0
    print!("> ");
231
0
    std::io::Write::flush(&mut std::io::stdout()).unwrap();
232
0
}
233
234
/// Read user input from stdin
235
///
236
/// # Returns
237
///
238
/// * `Ok(Some(input))` - User provided input
239
/// * `Ok(None)` - User wants to exit (empty input or "exit")
240
/// * `Err(error)` - Error reading input
241
0
fn read_user_input() -> Result<Option<String>, std::io::Error> {
242
0
    let mut input = String::new();
243
0
    std::io::stdin().read_line(&mut input)?;
244
245
0
    let input = input.trim();
246
0
    if input.is_empty() || input.to_lowercase() == "exit" {
247
0
        return Ok(None);
248
0
    }
249
250
0
    return Ok(Some(input.to_string()));
251
0
}
252
253
/// Handle special commands that don't need full parsing
254
///
255
/// # Arguments
256
///
257
/// * `input` - The user input string
258
/// * `args_command` - The ArgsCommand trait object for printing help
259
///
260
/// # Returns
261
///
262
/// * `true` - Command was handled, continue loop
263
/// * `false` - Command needs full parsing
264
9
fn handle_special_commands<A: ArgsCommand>(input: &str, args_command: &A) -> bool {
265
9
    if input == "--help" || 
input == "-h"8
{
266
2
        let _ = args_command.print_help();
267
2
        return true;
268
7
    }
269
7
    return false;
270
9
}
271
272
/// Execute a parsed command using the provided entrypoint
273
4
async fn execute_parsed_command<
274
4
    W: WindowsApi + Clone + 'static,
275
4
    T: Entrypoint,
276
4
    A: ArgsCommand,
277
4
    L: LoggerInitializer,
278
4
>(
279
4
    windows_api: &W,
280
4
    parsed_args: Args,
281
4
    entrypoint: &mut T,
282
4
    args_command: &A,
283
4
    logger_initializer: &L,
284
4
    config: &Config,
285
4
    config_path: &str,
286
4
) {
287
2
    match &parsed_args.command {
288
1
        Some(Commands::Client { host }) => {
289
1
            if parsed_args.debug {
290
0
                logger_initializer.init_logger(&format!("csshw_client_{host}"));
291
1
            }
292
1
            entrypoint
293
1
                .client_main(
294
1
                    windows_api,
295
1
                    host.to_owned(),
296
1
                    parsed_args.username.to_owned(),
297
1
                    parsed_args.port,
298
1
                    &config.client,
299
1
                )
300
1
                .await;
301
        }
302
        Some(Commands::Daemon {}) => {
303
1
            if parsed_args.debug {
304
1
                logger_initializer.init_logger("csshw_daemon");
305
1
            
}0
306
1
            entrypoint
307
1
                .daemon_main(
308
1
                    windows_api,
309
1
                    parsed_args.hosts,
310
1
                    parsed_args.username,
311
1
                    parsed_args.port,
312
1
                    &config.daemon,
313
1
                    &config.clusters,
314
1
                    parsed_args.debug,
315
1
                )
316
1
                .await;
317
        }
318
        None => {
319
2
            if !parsed_args.hosts.is_empty() {
320
1
                entrypoint.main(windows_api, config_path, config, parsed_args);
321
1
            } else {
322
1
                // Show help for empty hosts
323
1
                let _ = args_command.print_help();
324
1
            }
325
        }
326
    }
327
4
}
328
329
/// Run the interactive mode loop for GUI launches
330
0
async fn run_interactive_mode<W: WindowsApi + Clone + 'static, T: Entrypoint>(
331
0
    windows_api: &W,
332
0
    mut entrypoint: T,
333
0
    config: &Config,
334
0
    config_path: &str,
335
0
) {
336
    loop {
337
0
        show_interactive_prompt();
338
339
0
        match read_user_input() {
340
0
            Ok(Some(input)) => {
341
                // Handle special commands first
342
0
                if handle_special_commands(&input, &CLIArgsCommand) {
343
0
                    continue;
344
0
                }
345
346
                // Parse the input as command line arguments
347
0
                let input_args: Vec<&str> = input.split_whitespace().collect();
348
0
                let mut full_args = vec![PKG_NAME];
349
0
                full_args.extend(input_args);
350
351
0
                match Args::try_parse_from(full_args) {
352
0
                    Ok(parsed_args) => {
353
0
                        execute_parsed_command(
354
0
                            windows_api,
355
0
                            parsed_args,
356
0
                            &mut entrypoint,
357
0
                            &CLIArgsCommand,
358
0
                            &CLILoggerInitializer,
359
0
                            config,
360
0
                            config_path,
361
0
                        )
362
0
                        .await;
363
                    }
364
0
                    Err(err) => {
365
0
                        eprintln!("\nError parsing arguments: {err}");
366
0
                    }
367
                }
368
            }
369
            Ok(None) => {
370
0
                return;
371
            }
372
0
            Err(err) => {
373
0
                eprintln!("Error reading input: {err}");
374
0
            }
375
        }
376
    }
377
0
}
378
379
/// The main entrypoint
380
///
381
/// Parses the CLI arguments,
382
/// loads an existing config or writes the default config to disk, and
383
/// calls the respective subcommand.
384
/// If no subcommand is given we launch the daemon subcommand in a new window.
385
3
pub async fn main<W: WindowsApi + Clone + 'static, E: Entrypoint>(
386
3
    windows_api: &W,
387
3
    args: Args,
388
3
    mut entrypoint: E,
389
3
) {
390
    // CRITICAL: Check GUI launch BEFORE any output to console
391
3
    let launched_from_gui = is_launched_from_gui(windows_api);
392
393
    // Set DPI awareness programatically. Using the manifest is the recommended way
394
    // but conhost.exe does not do any manifest loading.
395
    // https://github.com/microsoft/terminal/issues/18464#issuecomment-2623392013
396
3
    if let Err(
err0
) = windows_api.set_process_dpi_awareness(PROCESS_PER_MONITOR_DPI_AWARE) {
397
0
        eprintln!("Failed to set DPI awareness programatically: {err:?}");
398
3
    }
399
3
    match std::env::current_exe() {
400
3
        Ok(path) => match path.parent() {
401
0
            None => {
402
0
                eprintln!("Failed to get executable path parent working directory");
403
0
            }
404
3
            Some(exe_dir) => {
405
3
                std::env::set_current_dir(exe_dir)
406
3
                    .expect("Failed to change current working directory");
407
3
            }
408
        },
409
0
        Err(_) => {
410
0
            eprintln!("Failed to get executable directory");
411
0
        }
412
    }
413
414
3
    let config_path = format!("{PKG_NAME}-config.toml");
415
3
    let config_on_disk: ConfigOpt = confy::load_path(&config_path).unwrap();
416
3
    let config: Config = config_on_disk.into();
417
418
2
    match &args.command {
419
1
        Some(Commands::Client { host }) => {
420
1
            if args.debug {
421
0
                init_logger(&format!("csshw_client_{host}"));
422
1
            }
423
1
            entrypoint
424
1
                .client_main(
425
1
                    windows_api,
426
1
                    host.to_owned(),
427
1
                    args.username.to_owned(),
428
1
                    args.port,
429
1
                    &config.client,
430
1
                )
431
1
                .await;
432
        }
433
        Some(Commands::Daemon {}) => {
434
1
            if args.debug {
435
0
                init_logger("csshw_daemon");
436
1
            }
437
1
            entrypoint
438
1
                .daemon_main(
439
1
                    windows_api,
440
1
                    args.hosts.to_owned(),
441
1
                    args.username.clone(),
442
1
                    args.port,
443
1
                    &config.daemon,
444
1
                    &config.clusters,
445
1
                    args.debug,
446
1
                )
447
1
                .await;
448
        }
449
        None => {
450
            // If no hosts provided, show help and handle GUI vs console launch
451
1
            if args.hosts.is_empty() {
452
                // Show help using clap's built-in help
453
0
                Args::command().print_help().unwrap();
454
455
                // If launched from GUI, allow user to input arguments interactively
456
0
                if launched_from_gui {
457
0
                    run_interactive_mode(windows_api, entrypoint, &config, &config_path).await;
458
0
                }
459
0
                return;
460
1
            }
461
462
1
            entrypoint.main(windows_api, &config_path, &config, args);
463
        }
464
    }
465
3
}
466
467
#[cfg(test)]
468
#[path = "./tests/test_cli.rs"]
469
mod test_cli;